Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit f736fe3fb0c7c907f1529b1ec072b76a5ccdd760


Parents : 591b6ef
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-17T17:23:28-05:00

feat: update Android notification handling with Do Not Disturb and conversation tracking features

Changes
Diff

diff --git a/android/app/src/main/java/com/meshchatx/AndroidNotificationBridge.java b/android/app/src/main/java/com/meshchatx/AndroidNotificationBridge.java
index ebaec61c..75255445 100644
--- a/android/app/src/main/java/com/meshchatx/AndroidNotificationBridge.java
+++ b/android/app/src/main/java/com/meshchatx/AndroidNotificationBridge.java
@@ -26,10 +26,65 @@ public final class AndroidNotificationBridge {
private static final int REQ_CALL_ANSWER = 0x4c33;
private static final int REQ_CALL_DECLINE = 0x4c34;
+ private static final Object CONTEXT_LOCK = new Object();
+ private static volatile boolean doNotDisturbEnabled = false;
+ private static volatile String openConversationHashesCsv = "";
+ private static final java.util.Set<Integer> postedMessageNotificationIds =
+ java.util.Collections.synchronizedSet(new java.util.HashSet<>());
+
private AndroidNotificationBridge() {
}
+ public static void setDoNotDisturbEnabled(boolean enabled) {
+ doNotDisturbEnabled = enabled;
+ }
+
+ public static boolean isDoNotDisturbEnabled() {
+ return doNotDisturbEnabled;
+ }
+
+ public static void setOpenConversationHashes(String csv) {
+ synchronized (CONTEXT_LOCK) {
+ openConversationHashesCsv = csv == null ? "" : csv.trim().toLowerCase();
+ }
+ }
+
+ public static String getOpenConversationHashesCsv() {
+ synchronized (CONTEXT_LOCK) {
+ return openConversationHashesCsv;
+ }
+ }
+
+ public static boolean isOpenConversationHash(String destinationHash) {
+ if (destinationHash == null) {
+ return false;
+ }
+ String needle = destinationHash.trim().toLowerCase();
+ if (needle.isEmpty()) {
+ return false;
+ }
+ String csv = getOpenConversationHashesCsv();
+ if (csv.isEmpty()) {
+ return false;
+ }
+ for (String part : csv.split(",")) {
+ if (needle.equals(part.trim())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
public static void showInboundMessage(String title, String body, @Nullable String dedupeHex) {
+ showInboundMessage(title, body, dedupeHex, null);
+ }
+
+ public static void showInboundMessage(
+ String title,
+ String body,
+ @Nullable String dedupeHex,
+ @Nullable String destinationHash
+ ) {
Context ctx = MeshChatApplication.getAppContext();
if (ctx == null) {
return;
@@ -37,7 +92,63 @@ public final class AndroidNotificationBridge {
String safeTitle = TextUtils.isEmpty(title) ? ctx.getString(R.string.app_name) : title;
String safeBody = TextUtils.isEmpty(body) ? ctx.getString(R.string.notification_new_message_fallback) : body;
- new Handler(Looper.getMainLooper()).post(() -> postInboundMessage(ctx, safeTitle, safeBody, dedupeHex));
+ new Handler(Looper.getMainLooper()).post(
+ () -> postInboundMessage(ctx, safeTitle, safeBody, dedupeHex, destinationHash)
+ );
+ }
+
+ public static void cancelMessageNotifications(@Nullable String destinationHash) {
+ Context ctx = MeshChatApplication.getAppContext();
+ if (ctx == null) {
+ return;
+ }
+ new Handler(Looper.getMainLooper()).post(
+ () -> {
+ NotificationManager nm = ctx.getSystemService(NotificationManager.class);
+ if (nm == null) {
+ return;
+ }
+ int id = messageNotificationId(null, destinationHash);
+ try {
+ nm.cancel(id);
+ } catch (Exception ignored) {
+ }
+ postedMessageNotificationIds.remove(id);
+ }
+ );
+ }
+
+ public static void cancelAllMessageNotifications() {
+ Context ctx = MeshChatApplication.getAppContext();
+ if (ctx == null) {
+ return;
+ }
+ new Handler(Looper.getMainLooper()).post(
+ () -> {
+ NotificationManager nm = ctx.getSystemService(NotificationManager.class);
+ if (nm == null) {
+ return;
+ }
+ Integer[] ids;
+ synchronized (postedMessageNotificationIds) {
+ ids = postedMessageNotificationIds.toArray(new Integer[0]);
+ postedMessageNotificationIds.clear();
+ }
+ for (Integer id : ids) {
+ if (id == null) {
+ continue;
+ }
+ try {
+ nm.cancel(id);
+ } catch (Exception ignored) {
+ }
+ }
+ try {
+ nm.cancel(NOTIFY_BASE_ID);
+ } catch (Exception ignored) {
+ }
+ }
+ );
}
public static void showIncomingCall(String callerName, @Nullable String dedupeHex) {
@@ -214,7 +325,35 @@ public final class AndroidNotificationBridge {
}
}
- private static void postInboundMessage(Context ctx, String title, String body, @Nullable String dedupeHex) {
+ private static int messageNotificationId(@Nullable String dedupeHex, @Nullable String destinationHash) {
+ if (destinationHash != null && destinationHash.length() >= 8) {
+ try {
+ return NOTIFY_BASE_ID
+ + (int) (Long.parseLong(
+ destinationHash.substring(0, Math.min(8, destinationHash.length())), 16) & 0x7fff_ffff);
+ } catch (NumberFormatException ignored) {
+ return NOTIFY_BASE_ID + (destinationHash.hashCode() & 0x7fff_ffff);
+ }
+ }
+ if (dedupeHex != null && dedupeHex.length() >= 8) {
+ try {
+ return NOTIFY_BASE_ID
+ + (int) (Long.parseLong(
+ dedupeHex.substring(0, Math.min(8, dedupeHex.length())), 16) & 0x7fff_ffff);
+ } catch (NumberFormatException ignored) {
+ return NOTIFY_BASE_ID + (dedupeHex.hashCode() & 0x7fff_ffff);
+ }
+ }
+ return NOTIFY_BASE_ID;
+ }
+
+ private static void postInboundMessage(
+ Context ctx,
+ String title,
+ String body,
+ @Nullable String dedupeHex,
+ @Nullable String destinationHash
+ ) {
NotificationManager nm = ctx.getSystemService(NotificationManager.class);
if (nm == null) {
return;
@@ -229,16 +368,7 @@ public final class AndroidNotificationBridge {
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
- int id = NOTIFY_BASE_ID;
- if (dedupeHex != null && dedupeHex.length() >= 8) {
- try {
- id = NOTIFY_BASE_ID
- + (int) (Long.parseLong(
- dedupeHex.substring(0, Math.min(8, dedupeHex.length())), 16) & 0x7fff_ffff);
- } catch (NumberFormatException ignored) {
- id = NOTIFY_BASE_ID + (dedupeHex.hashCode() & 0x7fff_ffff);
- }
- }
+ int id = messageNotificationId(dedupeHex, destinationHash);
try {
nm.cancel(id);
@@ -259,6 +389,7 @@ public final class AndroidNotificationBridge {
try {
nm.notify(id, b.build());
+ postedMessageNotificationIds.add(id);
} catch (SecurityException ignored) {
}
}

diff --git a/android/app/src/main/java/com/meshchatx/MainActivity.java b/android/app/src/main/java/com/meshchatx/MainActivity.java
index 127e99d6..721792ed 100644
--- a/android/app/src/main/java/com/meshchatx/MainActivity.java
+++ b/android/app/src/main/java/com/meshchatx/MainActivity.java
@@ -1643,7 +1643,32 @@ public class MainActivity extends AppCompatActivity {
@JavascriptInterface
public void showNotification(String title, String body) {
- AndroidNotificationBridge.showInboundMessage(title, body, null);
+ AndroidNotificationBridge.showInboundMessage(title, body, null, null);
+ }
+
+ @JavascriptInterface
+ public void showMessageNotification(String title, String body, String destinationHash) {
+ AndroidNotificationBridge.showInboundMessage(title, body, null, destinationHash);
+ }
+
+ @JavascriptInterface
+ public void cancelMessageNotifications(String destinationHash) {
+ AndroidNotificationBridge.cancelMessageNotifications(destinationHash);
+ }
+
+ @JavascriptInterface
+ public void cancelAllMessageNotifications() {
+ AndroidNotificationBridge.cancelAllMessageNotifications();
+ }
+
+ @JavascriptInterface
+ public void setOpenConversationHashes(String csv) {
+ AndroidNotificationBridge.setOpenConversationHashes(csv);
+ }
+
+ @JavascriptInterface
+ public void setDoNotDisturbEnabled(boolean enabled) {
+ AndroidNotificationBridge.setDoNotDisturbEnabled(enabled);
}
@JavascriptInterface

diff --git a/electron/main.js b/electron/main.js
index ea18481e..e8c1029c 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -154,12 +154,23 @@ ipcMain.handle("get-integrity-status", () => {
});
// Native Notification IPC
-ipcMain.handle("show-notification", (event, { title, body, silent }) => {
+const {
+ trackMessageNotification,
+ untrackMessageNotification,
+ closeMessageNotificationsFor,
+ closeAllMessageNotifications,
+} = require("./messageNotifications.js");
+
+ipcMain.handle("show-notification", (event, { title, body, silent, destinationHash }) => {
const notification = new Notification({
title: title,
body: body,
silent: silent,
});
+ trackMessageNotification(destinationHash, notification);
+ notification.on("close", () => {
+ untrackMessageNotification(destinationHash, notification);
+ });
notification.show();
notification.on("click", () => {
@@ -170,6 +181,13 @@ ipcMain.handle("show-notification", (event, { title, body, silent }) => {
});
});
+ipcMain.handle("close-message-notifications", (_event, destinationHash) => {
+ if (destinationHash) {
+ return closeMessageNotificationsFor(destinationHash);
+ }
+ return closeAllMessageNotifications();
+});
+
// Power Management IPC
ipcMain.handle("set-power-save-blocker", (event, enabled) => {
if (enabled) {

diff --git a/electron/messageNotifications.js b/electron/messageNotifications.js
new file mode 100644
index 00000000..29487f21
--- /dev/null
+++ b/electron/messageNotifications.js
@@ -0,0 +1,100 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * In-process map of Electron Notification instances keyed by destination hash.
+ * Extracted for unit tests without loading the full Electron main process.
+ */
+
+/** @type {Map<string, Set<{ close: () => void }>>} */
+const byDestination = new Map();
+
+/**
+ * @param {string|null|undefined} destinationHash
+ * @returns {string}
+ */
+export function normalizeNotifDestinationHash(destinationHash) {
+ if (destinationHash == null) {
+ return "";
+ }
+ return String(destinationHash).trim().toLowerCase();
+}
+
+/**
+ * @param {string|null|undefined} destinationHash
+ * @param {{ close: () => void }} notification
+ */
+export function trackMessageNotification(destinationHash, notification) {
+ const key = normalizeNotifDestinationHash(destinationHash) || "__untagged__";
+ let set = byDestination.get(key);
+ if (!set) {
+ set = new Set();
+ byDestination.set(key, set);
+ }
+ set.add(notification);
+}
+
+/**
+ * @param {string|null|undefined} destinationHash
+ * @param {{ close: () => void }} notification
+ */
+export function untrackMessageNotification(destinationHash, notification) {
+ const key = normalizeNotifDestinationHash(destinationHash) || "__untagged__";
+ const set = byDestination.get(key);
+ if (!set) {
+ return;
+ }
+ set.delete(notification);
+ if (set.size === 0) {
+ byDestination.delete(key);
+ }
+}
+
+/**
+ * @param {string|null|undefined} destinationHash
+ * @returns {number} closed count
+ */
+export function closeMessageNotificationsFor(destinationHash) {
+ const key = normalizeNotifDestinationHash(destinationHash);
+ if (!key) {
+ return 0;
+ }
+ const set = byDestination.get(key);
+ if (!set) {
+ return 0;
+ }
+ let closed = 0;
+ for (const n of Array.from(set)) {
+ try {
+ n.close();
+ closed += 1;
+ } catch {
+ // ignore
+ }
+ }
+ byDestination.delete(key);
+ return closed;
+}
+
+/**
+ * @returns {number} closed count
+ */
+export function closeAllMessageNotifications() {
+ let closed = 0;
+ for (const set of byDestination.values()) {
+ for (const n of Array.from(set)) {
+ try {
+ n.close();
+ closed += 1;
+ } catch {
+ // ignore
+ }
+ }
+ }
+ byDestination.clear();
+ return closed;
+}
+
+/** Test helper. */
+export function resetMessageNotificationTrackerForTests() {
+ byDestination.clear();
+}

diff --git a/electron/preload.js b/electron/preload.js
index eabcce43..9ef5f849 100644
--- a/electron/preload.js
+++ b/electron/preload.js
@@ -109,8 +109,11 @@ contextBridge.exposeInMainWorld("electron", {
return await ipcRenderer.invoke("get-integrity-status");
},
// allow showing a native notification
- showNotification: function (title, body, silent = false) {
- ipcRenderer.invoke("show-notification", { title, body, silent });
+ showNotification: function (title, body, silent = false, destinationHash = null) {
+ ipcRenderer.invoke("show-notification", { title, body, silent, destinationHash });
+ },
+ closeMessageNotifications: function (destinationHash = null) {
+ return ipcRenderer.invoke("close-message-notifications", destinationHash);
},
// allow controlling power save blocker
setPowerSaveBlocker: async function (enabled) {

diff --git a/meshchatx.rsm b/meshchatx.rsm
index 3c4bd6a8..21b90883 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/meshchatx/android_push_bridge.py b/meshchatx/android_push_bridge.py
index 153a4bbe..e323c924 100644
--- a/meshchatx/android_push_bridge.py
+++ b/meshchatx/android_push_bridge.py
@@ -8,6 +8,8 @@ import json
import logging
from typing import Any
+from meshchatx.src.backend.lxmf_utils import is_user_facing_lxmf_payload
+
logger = logging.getLogger("meshchatx.android_push_bridge")
_ws_hook_installed = False
@@ -40,6 +42,40 @@ def _get_android_external_files_dir() -> str | None:
return None
+def _android_notification_bridge():
+ try:
+ from com.meshchatx import (
+ AndroidNotificationBridge, # type: ignore[import-not-found,import-untyped]
+ )
+
+ return AndroidNotificationBridge
+ except Exception as exc:
+ logger.debug("Android notification bridge unavailable: %s", exc)
+ return None
+
+
+def _dnd_enabled() -> bool:
+ bridge = _android_notification_bridge()
+ if bridge is None:
+ return False
+ try:
+ return bool(bridge.isDoNotDisturbEnabled())
+ except Exception:
+ return False
+
+
+def _is_open_conversation(destination_hash: str | None) -> bool:
+ if not destination_hash:
+ return False
+ bridge = _android_notification_bridge()
+ if bridge is None:
+ return False
+ try:
+ return bool(bridge.isOpenConversationHash(destination_hash))
+ except Exception:
+ return False
+
+
def lxmf_delivery_notification_text(payload: dict[str, Any]) -> tuple[str, str] | None:
"""Return (title, body) for a system notification, or None to skip."""
if payload.get("type") != "lxmf.delivery":
@@ -51,16 +87,18 @@ def lxmf_delivery_notification_text(payload: dict[str, Any]) -> tuple[str, str]
return None
if not msg.get("is_incoming"):
return None
+ if not is_user_facing_lxmf_payload(
+ msg.get("fields"),
+ msg.get("content"),
+ msg.get("title"),
+ ):
+ return None
+ source_hash = msg.get("source_hash")
+ if isinstance(source_hash, str) and _is_open_conversation(source_hash):
+ return None
+ if _dnd_enabled():
+ return None
sender = str(payload.get("remote_identity_name") or "").strip() or "Mesh"
- if msg.get("is_reaction"):
- emoji = str(msg.get("reaction_emoji") or "").strip()
- body = f"Reaction {emoji}".strip() if emoji else "Reaction"
- return (sender, body)
- fields = msg.get("fields")
- if isinstance(fields, dict) and not msg.get("title") and not msg.get("content"):
- keys = set(fields.keys())
- if keys <= {"telemetry"}:
- return None
title = str(msg.get("title") or "").strip()
content = str(msg.get("content") or "").strip()
if len(content) > 200:
@@ -71,6 +109,7 @@ def lxmf_delivery_notification_text(payload: dict[str, Any]) -> tuple[str, str]
return (sender, title)
if content:
return (sender, content)
+ fields = msg.get("fields")
if isinstance(fields, dict) and fields.get("image"):
return (sender, "Image message")
if isinstance(fields, dict) and fields.get("audio"):
@@ -80,58 +119,53 @@ def lxmf_delivery_notification_text(payload: dict[str, Any]) -> tuple[str, str]
return (sender, "New message")
-def _notify_java(title: str, body: str, dedupe_hex: str | None) -> None:
- try:
- from com.meshchatx import (
- AndroidNotificationBridge, # type: ignore[import-not-found,import-untyped]
- )
- except Exception as exc:
- logger.debug("Android notification bridge unavailable: %s", exc)
+def _notify_java(
+ title: str,
+ body: str,
+ dedupe_hex: str | None,
+ destination_hash: str | None = None,
+) -> None:
+ bridge = _android_notification_bridge()
+ if bridge is None:
return
try:
- AndroidNotificationBridge.showInboundMessage(title, body, dedupe_hex)
+ bridge.showInboundMessage(title, body, dedupe_hex, destination_hash)
+ except TypeError:
+ # Older bridge signature without destination hash.
+ try:
+ bridge.showInboundMessage(title, body, dedupe_hex)
+ except Exception as exc:
+ logger.debug("showInboundMessage failed: %s", exc)
except Exception as exc:
logger.debug("showInboundMessage failed: %s", exc)
def _notify_incoming_call_java(caller_name: str, dedupe_hex: str | None) -> None:
- try:
- from com.meshchatx import (
- AndroidNotificationBridge, # type: ignore[import-not-found,import-untyped]
- )
- except Exception as exc:
- logger.debug("Android notification bridge unavailable: %s", exc)
+ bridge = _android_notification_bridge()
+ if bridge is None:
return
try:
- AndroidNotificationBridge.showIncomingCall(caller_name, dedupe_hex)
+ bridge.showIncomingCall(caller_name, dedupe_hex)
except Exception as exc:
logger.debug("showIncomingCall failed: %s", exc)
def _notify_missed_call_java(title: str, body: str, dedupe_hex: str | None) -> None:
- try:
- from com.meshchatx import (
- AndroidNotificationBridge, # type: ignore[import-not-found,import-untyped]
- )
- except Exception as exc:
- logger.debug("Android notification bridge unavailable: %s", exc)
+ bridge = _android_notification_bridge()
+ if bridge is None:
return
try:
- AndroidNotificationBridge.showMissedCall(title, body, dedupe_hex)
+ bridge.showMissedCall(title, body, dedupe_hex)
except Exception as exc:
logger.debug("showMissedCall failed: %s", exc)
def _cancel_incoming_call_notification_java() -> None:
- try:
- from com.meshchatx import (
- AndroidNotificationBridge, # type: ignore[import-not-found,import-untyped]
- )
- except Exception as exc:
- logger.debug("Android notification bridge unavailable: %s", exc)
+ bridge = _android_notification_bridge()
+ if bridge is None:
return
try:
- AndroidNotificationBridge.cancelIncomingCallNotification()
+ bridge.cancelIncomingCallNotification()
except Exception as exc:
logger.debug("cancelIncomingCallNotification failed: %s", exc)
@@ -150,12 +184,16 @@ def _after_websocket_broadcast(data: object) -> None:
_cancel_incoming_call_notification_java()
return
if t == "telephone_ringing":
+ if _dnd_enabled():
+ return
ch = payload.get("remote_identity_hash")
name = (payload.get("remote_identity_name") or "").strip() or "Mesh"
ded = ch if isinstance(ch, str) and len(ch) >= 8 else None
_notify_incoming_call_java(name, ded)
return
if t == "telephone_missed_call":
+ if _dnd_enabled():
+ return
sender = (payload.get("remote_identity_name") or "").strip() or "Mesh"
ch = payload.get("remote_identity_hash")
h = ch if isinstance(ch, str) and len(ch) >= 8 else None
@@ -177,11 +215,15 @@ def _after_websocket_broadcast(data: object) -> None:
title, body = pair
msg = payload.get("lxmf_message")
dedupe = None
+ destination_hash = None
if isinstance(msg, dict):
h = msg.get("hash")
if isinstance(h, str) and len(h) >= 8:
dedupe = h
- _notify_java(title, body, dedupe)
+ src = msg.get("source_hash")
+ if isinstance(src, str) and src.strip():
+ destination_hash = src.strip().lower()
+ _notify_java(title, body, dedupe, destination_hash)
def install_websocket_hook(reticulum_mesh_chat_cls: type) -> None:

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 8ec401bd..c2d4a9ad 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -607,6 +607,13 @@ import Utils from "../js/Utils";
import GlobalEmitter from "../js/GlobalEmitter";
import NotificationUtils from "../js/NotificationUtils";
import NotificationSoundUtils from "../js/NotificationSoundUtils";
+import {
+ deliverySourceHash,
+ isUserFacingLxmfDeliveryMessage,
+ shouldPlayMessageSound,
+ shouldShowOsMessageNotification,
+} from "../js/notificationPolicy.js";
+import { listOpenDestinationHashes, subscribeOpenDestinationHashes } from "../js/activeConversationStore.js";
import LxmfUserIcon from "./LxmfUserIcon.vue";
import Toast from "./Toast.vue";
import ConfirmDialog from "./ConfirmDialog.vue";
@@ -835,6 +842,10 @@ export default {
this.applyThemePreference(newConfig.theme ?? "light");
}
this.applyShellAppearance();
+ NotificationUtils.syncAndroidNotificationContext(
+ listOpenDestinationHashes(),
+ Boolean(newConfig?.do_not_disturb_enabled)
+ );
},
deep: true,
},
@@ -861,6 +872,10 @@ export default {
window.removeEventListener("meshchatx-intent-uri", this.onAndroidIntentUri);
window.removeEventListener("pointerdown", this.onRingtoneUnlockGesture, true);
window.removeEventListener("keydown", this.onRingtoneUnlockGesture, true);
+ if (typeof this._unsubOpenConversations === "function") {
+ this._unsubOpenConversations();
+ this._unsubOpenConversations = null;
+ }
},
mounted() {
try {
@@ -898,6 +913,13 @@ export default {
window.addEventListener("meshchatx-intent-uri", this.onAndroidIntentUri);
window.addEventListener("pointerdown", this.onRingtoneUnlockGesture, true);
window.addEventListener("keydown", this.onRingtoneUnlockGesture, true);
+ this._unsubOpenConversations = subscribeOpenDestinationHashes((hashes) => {
+ NotificationUtils.syncAndroidNotificationContext(hashes, Boolean(this.config?.do_not_disturb_enabled));
+ });
+ NotificationUtils.syncAndroidNotificationContext(
+ listOpenDestinationHashes(),
+ Boolean(this.config?.do_not_disturb_enabled)
+ );
},
methods: {
isNavItemVisible(item) {
@@ -1495,19 +1517,38 @@ export default {
if (json.sieve_suppress_notifications) {
return;
}
- this.updateUnreadConversationsCount();
- const isIncomingMessage =
- json.lxmf_message?.is_incoming === true &&
- (json.lxmf_message?.content || json.lxmf_message?.title);
+ const lxmfMessage = json.lxmf_message;
+ const isIncoming = lxmfMessage?.is_incoming === true;
+ const userFacing = isUserFacingLxmfDeliveryMessage(lxmfMessage);
+ const sourceHash = deliverySourceHash(json);
+ const openHashes = listOpenDestinationHashes();
+ const sourceOpen = openHashes.includes(String(sourceHash || "").toLowerCase());
+ const hasFocus = typeof document !== "undefined" ? document.hasFocus() : true;
+ const policyBase = {
+ isIncoming,
+ sieveSuppress: Boolean(json.sieve_suppress_notifications),
+ dnd: Boolean(this.config?.do_not_disturb_enabled),
+ hasFocus,
+ openDestinationHashes: openHashes,
+ sourceHash,
+ userFacing,
+ };
+
+ // Open peers are mark-as-read by ConversationViewer; still refresh for other peers.
+ if (isIncoming && userFacing && !sourceOpen) {
+ this.updateUnreadConversationsCount();
+ }
+
let playedNotificationSound = false;
- if (isIncomingMessage) {
+ if (shouldPlayMessageSound(policyBase)) {
playedNotificationSound = await NotificationSoundUtils.play(this.config);
}
- if (!document.hasFocus() && isIncomingMessage) {
+ if (shouldShowOsMessageNotification(policyBase)) {
NotificationUtils.showNewMessageNotification(
json.remote_identity_name,
- json.lxmf_message?.content,
- playedNotificationSound
+ lxmfMessage?.content || lxmfMessage?.title || "",
+ playedNotificationSound,
+ sourceHash
);
}
},

diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index f29a7503..49197ecd 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -1811,6 +1811,7 @@ import AudioWaveformPlayer from "./AudioWaveformPlayer.vue";
import LxmfUserIcon from "../LxmfUserIcon.vue";
import GlobalEmitter from "../../js/GlobalEmitter";
import ToastUtils from "../../js/ToastUtils";
+import NotificationUtils from "../../js/NotificationUtils";
import PaperMessageModal from "./PaperMessageModal.vue";
import GlobalState from "../../js/GlobalState";
import MarkdownRenderer from "../../js/MarkdownRenderer";
@@ -7170,6 +7171,7 @@ export default {
try {
await window.api.post(`/api/v1/lxmf/conversations/${conversation.destination_hash}/mark-as-read`);
GlobalEmitter.emit("notifications-changed");
+ NotificationUtils.clearMessageNotifications(conversation.destination_hash);
if (wasUnread && GlobalState.unreadConversationsCount > 0) {
GlobalState.unreadConversationsCount -= 1;
}

diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index 69c63ade..f56e9d5f 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -326,6 +326,8 @@ import DialogUtils from "../../js/DialogUtils";
import DownloadUtils from "../../js/DownloadUtils";
import GlobalEmitter from "../../js/GlobalEmitter";
import ToastUtils from "../../js/ToastUtils";
+import NotificationUtils from "../../js/NotificationUtils";
+import { setOpenDestinationHashes } from "../../js/activeConversationStore.js";
import {
attachStreamToVideo,
decodeQrFromVideo,
@@ -507,6 +509,7 @@ export default {
},
paneLayoutSignature() {
this.persistPanes();
+ this.syncOpenDestinationHashes();
},
destinationHash(newHash) {
if (!newHash) {
@@ -531,6 +534,7 @@ export default {
this.boundPaneResizeMove = null;
this.boundPaneResizeEnd = null;
this.restorePanes(this.destinationHash);
+ this.syncOpenDestinationHashes();
},
beforeUnmount() {
clearInterval(this.reloadInterval);
@@ -541,6 +545,7 @@ export default {
this.stopIngestScanner();
this.teardownPaneViewportWatchers();
this.teardownPaneResize();
+ setOpenDestinationHashes([]);
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
@@ -1071,6 +1076,9 @@ export default {
destination_hashes,
});
GlobalEmitter.emit("notifications-changed");
+ for (const h of destination_hashes || []) {
+ NotificationUtils.clearMessageNotifications(h);
+ }
await this.getConversations();
ToastUtils.success(this.$t("messages.marked_read"));
} catch {
@@ -1083,6 +1091,7 @@ export default {
mark_all: true,
});
GlobalEmitter.emit("notifications-changed");
+ NotificationUtils.clearAllMessageNotifications();
await this.getConversations();
ToastUtils.success(this.$t("messages.marked_all_read"));
} catch {
@@ -1285,6 +1294,13 @@ export default {
focusedIndex: focusedIndex < 0 ? 0 : focusedIndex,
});
},
+ syncOpenDestinationHashes() {
+ const hashes = this.panes
+ .map((pane) => pane.peer?.destination_hash)
+ .filter((h) => typeof h === "string" && h.length > 0);
+ setOpenDestinationHashes(hashes);
+ NotificationUtils.syncAndroidNotificationContext(hashes, Boolean(this.config?.do_not_disturb_enabled));
+ },
applyToPanePeers(destinationHash, patch) {
for (const pane of this.panes) {
if (pane.peer && pane.peer.destination_hash === destinationHash) {

diff --git a/meshchatx/src/frontend/js/NotificationUtils.js b/meshchatx/src/frontend/js/NotificationUtils.js
index 8f50e883..d41cd365 100644
--- a/meshchatx/src/frontend/js/NotificationUtils.js
+++ b/meshchatx/src/frontend/js/NotificationUtils.js
@@ -1,4 +1,9 @@
+// SPDX-License-Identifier: 0BSD
+
class NotificationUtils {
+ /** @type {Map<string, { close: () => void }[]>} */
+ static _webMessageNotifications = new Map();
+
static _isAndroid() {
return Boolean(
typeof window !== "undefined" &&
@@ -8,6 +13,36 @@ class NotificationUtils {
);
}
+ /**
+ * Android OS message notifs are owned by the Python push bridge.
+ * @returns {boolean}
+ */
+ static ownsOsMessageNotifications() {
+ return !NotificationUtils._isAndroid();
+ }
+
+ static _normalizeHash(destinationHash) {
+ if (destinationHash == null) {
+ return "";
+ }
+ return String(destinationHash).trim().toLowerCase();
+ }
+
+ static _messageTag(destinationHash) {
+ const h = NotificationUtils._normalizeHash(destinationHash);
+ return h ? `lxmf-${h}` : "new_message";
+ }
+
+ static _trackWebNotification(destinationHash, notification) {
+ const key = NotificationUtils._normalizeHash(destinationHash) || "__untagged__";
+ let list = NotificationUtils._webMessageNotifications.get(key);
+ if (!list) {
+ list = [];
+ NotificationUtils._webMessageNotifications.set(key, list);
+ }
+ list.push(notification);
+ }
+
static showIncomingCallNotification(callerName) {
if (window.electron) {
window.electron.showNotification(
@@ -68,32 +103,126 @@ class NotificationUtils {
});
}
- static showNewMessageNotification(from, content, silent = false) {
- if (window.electron) {
- window.electron.showNotification(
- "New Message",
- from ? `${from}: ${content || "Sent a message."}` : "Someone sent you a message.",
- silent
- );
+ /**
+ * @param {string} from
+ * @param {string} content
+ * @param {boolean} [silent]
+ * @param {string|null} [destinationHash]
+ */
+ static showNewMessageNotification(from, content, silent = false, destinationHash = null) {
+ if (!NotificationUtils.ownsOsMessageNotifications()) {
return;
}
- if (NotificationUtils._isAndroid()) {
- window.MeshChatXAndroid.showNotification(
- "New Message",
- from ? `${from}: ${content || "Sent a message."}` : "Someone sent you a message."
- );
+ const body = from ? `${from}: ${content || "Sent a message."}` : "Someone sent you a message.";
+ const hash = NotificationUtils._normalizeHash(destinationHash);
+
+ if (window.electron) {
+ if (typeof window.electron.showNotification === "function") {
+ window.electron.showNotification("New Message", body, silent, hash || null);
+ }
return;
}
+
Notification.requestPermission().then((result) => {
- if (result === "granted") {
- new window.Notification("New Message", {
- body: from ? `${from}: ${content || "Sent a message."}` : "Someone sent you a message.",
- tag: "new_message",
- });
+ if (result !== "granted") {
+ return;
}
+ const notification = new window.Notification("New Message", {
+ body,
+ tag: NotificationUtils._messageTag(hash),
+ silent: Boolean(silent),
+ });
+ NotificationUtils._trackWebNotification(hash, notification);
+ notification.onclose = () => {
+ const key = hash || "__untagged__";
+ const list = NotificationUtils._webMessageNotifications.get(key);
+ if (!list) {
+ return;
+ }
+ const idx = list.indexOf(notification);
+ if (idx >= 0) {
+ list.splice(idx, 1);
+ }
+ if (list.length === 0) {
+ NotificationUtils._webMessageNotifications.delete(key);
+ }
+ };
});
}
+ /**
+ * Clear OS message notifications for a peer, or all when hash is empty.
+ * @param {string|null|undefined} [destinationHash]
+ */
+ static clearMessageNotifications(destinationHash) {
+ const hash = NotificationUtils._normalizeHash(destinationHash);
+
+ if (window.electron && typeof window.electron.closeMessageNotifications === "function") {
+ window.electron.closeMessageNotifications(hash || null);
+ }
+
+ if (NotificationUtils._isAndroid()) {
+ if (hash && typeof window.MeshChatXAndroid.cancelMessageNotifications === "function") {
+ window.MeshChatXAndroid.cancelMessageNotifications(hash);
+ } else if (!hash && typeof window.MeshChatXAndroid.cancelAllMessageNotifications === "function") {
+ window.MeshChatXAndroid.cancelAllMessageNotifications();
+ }
+ }
+
+ if (hash) {
+ const list = NotificationUtils._webMessageNotifications.get(hash) || [];
+ for (const n of list.slice()) {
+ try {
+ n.close();
+ } catch {
+ // ignore
+ }
+ }
+ NotificationUtils._webMessageNotifications.delete(hash);
+ return;
+ }
+
+ for (const [, list] of NotificationUtils._webMessageNotifications) {
+ for (const n of list.slice()) {
+ try {
+ n.close();
+ } catch {
+ // ignore
+ }
+ }
+ }
+ NotificationUtils._webMessageNotifications.clear();
+ }
+
+ static clearAllMessageNotifications() {
+ NotificationUtils.clearMessageNotifications(null);
+ }
+
+ /**
+ * Sync open peers + DND into the Android Python push bridge via Java.
+ * @param {string[]} hashes
+ * @param {boolean} [dndEnabled]
+ */
+ static syncAndroidNotificationContext(hashes, dndEnabled = false) {
+ if (!NotificationUtils._isAndroid()) {
+ return;
+ }
+ try {
+ if (typeof window.MeshChatXAndroid.setOpenConversationHashes === "function") {
+ const joined = (hashes || [])
+ .map((h) => NotificationUtils._normalizeHash(h))
+ .filter(Boolean)
+ .join(",");
+ window.MeshChatXAndroid.setOpenConversationHashes(joined);
+ }
+ if (typeof window.MeshChatXAndroid.setDoNotDisturbEnabled === "function") {
+ window.MeshChatXAndroid.setDoNotDisturbEnabled(Boolean(dndEnabled));
+ }
+ } catch (e) {
+ console.error("Failed to sync Android notification context", e);
+ }
+ }
+
static cancelIncomingCallNotification() {
if (NotificationUtils._isAndroid()) {
window.MeshChatXAndroid.cancelIncomingCallNotification();

diff --git a/meshchatx/src/frontend/js/activeConversationStore.js b/meshchatx/src/frontend/js/activeConversationStore.js
new file mode 100644
index 00000000..42c47ed3
--- /dev/null
+++ b/meshchatx/src/frontend/js/activeConversationStore.js
@@ -0,0 +1,117 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Tracks destination hashes open in Messages panes for notification suppress/clear.
+ */
+
+import { normalizeDestinationHash } from "./notificationPolicy.js";
+
+/** @type {Set<string>} */
+const openHashes = new Set();
+
+/** @type {Set<(hashes: string[]) => void>} */
+const listeners = new Set();
+
+function notifyListeners() {
+ const snapshot = listOpenDestinationHashes();
+ for (const listener of listeners) {
+ try {
+ listener(snapshot);
+ } catch (e) {
+ console.error("activeConversationStore listener failed", e);
+ }
+ }
+}
+
+/**
+ * Replace the full set of open conversation destination hashes.
+ * @param {Iterable<string>|string[]|null|undefined} hashes
+ */
+export function setOpenDestinationHashes(hashes) {
+ const next = new Set();
+ if (hashes) {
+ for (const h of hashes) {
+ const n = normalizeDestinationHash(h);
+ if (n) {
+ next.add(n);
+ }
+ }
+ }
+ let changed = next.size !== openHashes.size;
+ if (!changed) {
+ for (const h of next) {
+ if (!openHashes.has(h)) {
+ changed = true;
+ break;
+ }
+ }
+ }
+ if (!changed) {
+ return;
+ }
+ openHashes.clear();
+ for (const h of next) {
+ openHashes.add(h);
+ }
+ notifyListeners();
+}
+
+/**
+ * @param {string} hash
+ */
+export function addOpenDestinationHash(hash) {
+ const n = normalizeDestinationHash(hash);
+ if (!n || openHashes.has(n)) {
+ return;
+ }
+ openHashes.add(n);
+ notifyListeners();
+}
+
+/**
+ * @param {string} hash
+ */
+export function removeOpenDestinationHash(hash) {
+ const n = normalizeDestinationHash(hash);
+ if (!n || !openHashes.has(n)) {
+ return;
+ }
+ openHashes.delete(n);
+ notifyListeners();
+}
+
+/**
+ * @returns {string[]}
+ */
+export function listOpenDestinationHashes() {
+ return Array.from(openHashes);
+}
+
+/**
+ * @param {string|null|undefined} hash
+ * @returns {boolean}
+ */
+export function hasOpenDestinationHash(hash) {
+ const n = normalizeDestinationHash(hash);
+ return Boolean(n && openHashes.has(n));
+}
+
+/**
+ * @param {(hashes: string[]) => void} listener
+ * @returns {() => void} unsubscribe
+ */
+export function subscribeOpenDestinationHashes(listener) {
+ if (typeof listener !== "function") {
+ return () => {};
+ }
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
+
+/** Test helper. */
+export function clearOpenDestinationHashesForTests() {
+ openHashes.clear();
+ listeners.clear();
+}

diff --git a/meshchatx/src/frontend/js/notificationPolicy.js b/meshchatx/src/frontend/js/notificationPolicy.js
new file mode 100644
index 00000000..ed0cec2e
--- /dev/null
+++ b/meshchatx/src/frontend/js/notificationPolicy.js
@@ -0,0 +1,154 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Pure policy for LXMF message OS notifications and in-app sound.
+ * Open peer means any messages pane currently showing that destination.
+ */
+
+/**
+ * @param {object} opts
+ * @param {boolean} [opts.isIncoming]
+ * @param {boolean} [opts.sieveSuppress]
+ * @param {boolean} [opts.dnd]
+ * @param {boolean} [opts.hasFocus]
+ * @param {Iterable<string>|string[]|Set<string>|null} [opts.openDestinationHashes]
+ * @param {string|null|undefined} [opts.sourceHash]
+ * @param {boolean} [opts.userFacing]
+ * @returns {boolean}
+ */
+export function shouldShowOsMessageNotification({
+ isIncoming = false,
+ sieveSuppress = false,
+ dnd = false,
+ hasFocus = true,
+ openDestinationHashes = null,
+ sourceHash = null,
+ userFacing = true,
+} = {}) {
+ if (dnd || sieveSuppress) {
+ return false;
+ }
+ if (!isIncoming || !userFacing) {
+ return false;
+ }
+ const src = normalizeDestinationHash(sourceHash);
+ if (src && isOpenDestination(src, openDestinationHashes)) {
+ return false;
+ }
+ if (hasFocus) {
+ return false;
+ }
+ return true;
+}
+
+/**
+ * @param {object} opts
+ * @param {boolean} [opts.isIncoming]
+ * @param {boolean} [opts.sieveSuppress]
+ * @param {boolean} [opts.dnd]
+ * @param {boolean} [opts.hasFocus]
+ * @param {boolean} [opts.userFacing]
+ * @returns {boolean}
+ */
+export function shouldPlayMessageSound({
+ isIncoming = false,
+ sieveSuppress = false,
+ dnd = false,
+ hasFocus = true,
+ userFacing = true,
+} = {}) {
+ if (dnd || sieveSuppress) {
+ return false;
+ }
+ if (!isIncoming || !userFacing) {
+ return false;
+ }
+ return Boolean(hasFocus);
+}
+
+/**
+ * Lightweight frontend mirror of backend user-facing LXMF filter for delivery events.
+ * @param {object|null|undefined} lxmfMessage
+ * @returns {boolean}
+ */
+export function isUserFacingLxmfDeliveryMessage(lxmfMessage) {
+ if (!lxmfMessage || typeof lxmfMessage !== "object") {
+ return false;
+ }
+ if (lxmfMessage.is_reaction === true) {
+ return false;
+ }
+ const fields = lxmfMessage.fields;
+ if (fields && typeof fields === "object") {
+ const reaction = fields.reaction;
+ if (reaction && typeof reaction === "object" && reaction.reaction_to) {
+ return false;
+ }
+ }
+ const title = typeof lxmfMessage.title === "string" ? lxmfMessage.title.trim() : "";
+ const content = typeof lxmfMessage.content === "string" ? lxmfMessage.content.trim() : "";
+ if (title || content) {
+ return true;
+ }
+ if (fields && typeof fields === "object") {
+ if (fields.image || fields.audio || fields.file_attachments) {
+ return true;
+ }
+ const keys = Object.keys(fields);
+ if (keys.length === 0) {
+ return false;
+ }
+ if (keys.every((k) => k === "telemetry")) {
+ return false;
+ }
+ }
+ return false;
+}
+
+/**
+ * Peer hash for an incoming LXMF delivery payload (conversation key).
+ * @param {object|null|undefined} json
+ * @returns {string}
+ */
+export function deliverySourceHash(json) {
+ if (!json || typeof json !== "object") {
+ return "";
+ }
+ const msg = json.lxmf_message;
+ if (msg && typeof msg === "object") {
+ const fromMsg = normalizeDestinationHash(msg.source_hash);
+ if (fromMsg) {
+ return fromMsg;
+ }
+ }
+ return normalizeDestinationHash(json.remote_identity_hash);
+}
+
+/**
+ * @param {string|null|undefined} hash
+ * @returns {string}
+ */
+export function normalizeDestinationHash(hash) {
+ if (hash === undefined || hash === null) {
+ return "";
+ }
+ return String(hash).trim().toLowerCase();
+}
+
+/**
+ * @param {string} sourceHash
+ * @param {Iterable<string>|string[]|Set<string>|null|undefined} openDestinationHashes
+ * @returns {boolean}
+ */
+export function isOpenDestination(sourceHash, openDestinationHashes) {
+ const src = normalizeDestinationHash(sourceHash);
+ if (!src || !openDestinationHashes) {
+ return false;
+ }
+ for (const h of openDestinationHashes) {
+ if (normalizeDestinationHash(h) === src) {
+ return true;
+ }
+ }
+ return false;
+}

diff --git a/tests/electron/messageNotifications.test.js b/tests/electron/messageNotifications.test.js
new file mode 100644
index 00000000..cab03739
--- /dev/null
+++ b/tests/electron/messageNotifications.test.js
@@ -0,0 +1,41 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import {
+ trackMessageNotification,
+ closeMessageNotificationsFor,
+ closeAllMessageNotifications,
+ resetMessageNotificationTrackerForTests,
+} from "../../electron/messageNotifications.js";
+
+describe("electron/messageNotifications", () => {
+ beforeEach(() => {
+ resetMessageNotificationTrackerForTests();
+ });
+
+ it("closes notifications for a destination hash", () => {
+ const a = { close: vi.fn() };
+ const b = { close: vi.fn() };
+ trackMessageNotification("aaaa", a);
+ trackMessageNotification("bbbb", b);
+ expect(closeMessageNotificationsFor("aaaa")).toBe(1);
+ expect(a.close).toHaveBeenCalledTimes(1);
+ expect(b.close).not.toHaveBeenCalled();
+ expect(closeMessageNotificationsFor("aaaa")).toBe(0);
+ });
+
+ it("closeAll closes every tracked notification", () => {
+ const a = { close: vi.fn() };
+ const b = { close: vi.fn() };
+ trackMessageNotification("aaaa", a);
+ trackMessageNotification("bbbb", b);
+ expect(closeAllMessageNotifications()).toBe(2);
+ expect(a.close).toHaveBeenCalled();
+ expect(b.close).toHaveBeenCalled();
+ });
+
+ it("unknown hash is a no-op", () => {
+ expect(closeMessageNotificationsFor("missing")).toBe(0);
+ expect(closeMessageNotificationsFor("")).toBe(0);
+ });
+});

diff --git a/tests/electron/preload.test.js b/tests/electron/preload.test.js
index b5ddf5bb..51d3443f 100644
--- a/tests/electron/preload.test.js
+++ b/tests/electron/preload.test.js
@@ -49,7 +49,23 @@ describe("electron/preload", () => {
expect(invoke).toHaveBeenCalledWith("is-hardware-acceleration-enabled");
api.showNotification("t", "b", true);
- expect(invoke).toHaveBeenCalledWith("show-notification", { title: "t", body: "b", silent: true });
+ expect(invoke).toHaveBeenCalledWith("show-notification", {
+ title: "t",
+ body: "b",
+ silent: true,
+ destinationHash: null,
+ });
+ api.showNotification("t2", "b2", false, "peerhash");
+ expect(invoke).toHaveBeenCalledWith("show-notification", {
+ title: "t2",
+ body: "b2",
+ silent: false,
+ destinationHash: "peerhash",
+ });
+ api.closeMessageNotifications("abcd");
+ expect(invoke).toHaveBeenCalledWith("close-message-notifications", "abcd");
+ api.closeMessageNotifications();
+ expect(invoke).toHaveBeenCalledWith("close-message-notifications", null);
});
it("onProtocolLink registers ipc listener for open-protocol-link", () => {

diff --git a/tests/frontend/ConversationViewer.test.js b/tests/frontend/ConversationViewer.test.js
index 6b301afd..a1947142 100644
--- a/tests/frontend/ConversationViewer.test.js
+++ b/tests/frontend/ConversationViewer.test.js
@@ -8,6 +8,7 @@ import ToastUtils from "@/js/ToastUtils";
import { MESSAGE_BODY_MAX_DISPLAY_CHARS } from "../../meshchatx/src/frontend/js/messageDisplayLimits.js";
import DownloadUtils from "@/js/DownloadUtils";
import GlobalEmitter from "@/js/GlobalEmitter";
+import NotificationUtils from "@/js/NotificationUtils";
vi.mock("@/js/DialogUtils", () => ({
default: {
@@ -15,6 +16,15 @@ vi.mock("@/js/DialogUtils", () => ({
},
}));
+vi.mock("@/js/NotificationUtils", () => ({
+ default: {
+ clearMessageNotifications: vi.fn(),
+ clearAllMessageNotifications: vi.fn(),
+ showNewMessageNotification: vi.fn(),
+ syncAndroidNotificationContext: vi.fn(),
+ },
+}));
+
vi.mock("@/js/GlobalEmitter", () => ({
default: {
on: vi.fn(),
@@ -127,6 +137,7 @@ describe("ConversationViewer.vue", () => {
await flushPromises();
axiosMock.post.mockClear();
GlobalEmitter.emit.mockClear();
+ NotificationUtils.clearMessageNotifications.mockClear();
const conversation = { destination_hash: "open-hash", is_unread: false };
await wrapper.vm.markConversationAsRead(conversation, { force: true });
@@ -136,6 +147,7 @@ describe("ConversationViewer.vue", () => {
const markCalls = axiosMock.post.mock.calls.filter((c) => String(c[0]).includes("/mark-as-read"));
expect(markCalls).toHaveLength(1);
expect(GlobalEmitter.emit).toHaveBeenCalledWith("notifications-changed");
+ expect(NotificationUtils.clearMessageNotifications).toHaveBeenCalledWith("open-hash");
});
it("onLxmfMessageReceived force marks the open conversation as read", async () => {
@@ -146,6 +158,7 @@ describe("ConversationViewer.vue", () => {
});
await flushPromises();
axiosMock.post.mockClear();
+ NotificationUtils.clearMessageNotifications.mockClear();
wrapper.vm.onLxmfMessageReceived({
source_hash: "open-peer",
@@ -158,6 +171,7 @@ describe("ConversationViewer.vue", () => {
const markCalls = axiosMock.post.mock.calls.filter((c) => String(c[0]).includes("/mark-as-read"));
expect(markCalls).toHaveLength(1);
expect(conversations[0].is_unread).toBe(false);
+ expect(NotificationUtils.clearMessageNotifications).toHaveBeenCalledWith("open-peer");
});
it("markConversationAsRead marks read without reloading conversations when conversation is unread", async () => {
@@ -165,6 +179,7 @@ describe("ConversationViewer.vue", () => {
await flushPromises();
axiosMock.post.mockClear();
GlobalEmitter.emit.mockClear();
+ NotificationUtils.clearMessageNotifications.mockClear();
const conversation = { destination_hash: "unread-hash", is_unread: true };
await wrapper.vm.markConversationAsRead(conversation);
@@ -175,6 +190,7 @@ describe("ConversationViewer.vue", () => {
expect(markCalls).toHaveLength(1);
expect(wrapper.emitted("reload-conversations")).toBeFalsy();
expect(GlobalEmitter.emit).toHaveBeenCalledWith("notifications-changed");
+ expect(NotificationUtils.clearMessageNotifications).toHaveBeenCalledWith("unread-hash");
});
it("markConversationAsRead does not notify bell when server mark-as-read fails", async () => {
@@ -187,12 +203,14 @@ describe("ConversationViewer.vue", () => {
return Promise.resolve({ data: {} });
});
GlobalEmitter.emit.mockClear();
+ NotificationUtils.clearMessageNotifications.mockClear();
const conversation = { destination_hash: "unread-hash", is_unread: true };
await wrapper.vm.markConversationAsRead(conversation);
await flushPromises();
expect(GlobalEmitter.emit).not.toHaveBeenCalledWith("notifications-changed");
+ expect(NotificationUtils.clearMessageNotifications).not.toHaveBeenCalled();
expect(conversation.is_unread).toBe(true);
});

diff --git a/tests/frontend/MessagesPage.test.js b/tests/frontend/MessagesPage.test.js
index 7cd1bdfa..22d4f6d7 100644
--- a/tests/frontend/MessagesPage.test.js
+++ b/tests/frontend/MessagesPage.test.js
@@ -2,6 +2,7 @@ import { mount, flushPromises } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import MessagesPage from "@/components/messages/MessagesPage.vue";
import GlobalEmitter from "@/js/GlobalEmitter";
+import NotificationUtils from "@/js/NotificationUtils";
vi.mock("@/js/GlobalEmitter", () => ({
default: {
@@ -11,6 +12,15 @@ vi.mock("@/js/GlobalEmitter", () => ({
},
}));
+vi.mock("@/js/NotificationUtils", () => ({
+ default: {
+ clearMessageNotifications: vi.fn(),
+ clearAllMessageNotifications: vi.fn(),
+ showNewMessageNotification: vi.fn(),
+ syncAndroidNotificationContext: vi.fn(),
+ },
+}));
+
describe("MessagesPage.vue", () => {
let axiosMock;
@@ -612,6 +622,7 @@ describe("MessagesPage.vue", () => {
axiosMock.post.mockResolvedValue({ data: {} });
axiosMock.get.mockResolvedValue({ data: { conversations: [] } });
GlobalEmitter.emit.mockClear();
+ NotificationUtils.clearMessageNotifications.mockClear();
await wrapper.vm.onBulkMarkAsRead(["peer-a", "peer-b"]);
await wrapper.vm.$nextTick();
@@ -620,6 +631,8 @@ describe("MessagesPage.vue", () => {
destination_hashes: ["peer-a", "peer-b"],
});
expect(GlobalEmitter.emit).toHaveBeenCalledWith("notifications-changed");
+ expect(NotificationUtils.clearMessageNotifications).toHaveBeenCalledWith("peer-a");
+ expect(NotificationUtils.clearMessageNotifications).toHaveBeenCalledWith("peer-b");
});
it("onMarkAllAsRead posts mark_all and refreshes conversations", async () => {
@@ -628,6 +641,7 @@ describe("MessagesPage.vue", () => {
axiosMock.post.mockResolvedValue({ data: {} });
axiosMock.get.mockResolvedValue({ data: { conversations: [] } });
GlobalEmitter.emit.mockClear();
+ NotificationUtils.clearAllMessageNotifications.mockClear();
const getConversations = vi.spyOn(wrapper.vm, "getConversations").mockResolvedValue(undefined);
await wrapper.vm.onMarkAllAsRead();
@@ -638,6 +652,7 @@ describe("MessagesPage.vue", () => {
});
expect(GlobalEmitter.emit).toHaveBeenCalledWith("notifications-changed");
expect(getConversations).toHaveBeenCalled();
+ expect(NotificationUtils.clearAllMessageNotifications).toHaveBeenCalled();
});
it("onBulkMarkAsRead does not notify bell when server rejects", async () => {
@@ -645,10 +660,12 @@ describe("MessagesPage.vue", () => {
await wrapper.vm.$nextTick();
axiosMock.post.mockRejectedValue(new Error("fail"));
GlobalEmitter.emit.mockClear();
+ NotificationUtils.clearMessageNotifications.mockClear();
await wrapper.vm.onBulkMarkAsRead(["peer-a"]);
await wrapper.vm.$nextTick();
expect(GlobalEmitter.emit).not.toHaveBeenCalledWith("notifications-changed");
+ expect(NotificationUtils.clearMessageNotifications).not.toHaveBeenCalled();
});
});

diff --git a/tests/frontend/NotificationUtils.test.js b/tests/frontend/NotificationUtils.test.js
index bdfc742b..983b4abb 100644
--- a/tests/frontend/NotificationUtils.test.js
+++ b/tests/frontend/NotificationUtils.test.js
@@ -8,16 +8,27 @@ describe("NotificationUtils", () => {
beforeEach(() => {
originalNotification = globalThis.Notification;
- electronMock = { showNotification: vi.fn() };
+ NotificationUtils._webMessageNotifications.clear();
+ electronMock = {
+ showNotification: vi.fn(),
+ closeMessageNotifications: vi.fn(),
+ };
androidMock = {
getPlatform: vi.fn().mockReturnValue("android"),
showNotification: vi.fn(),
showIncomingCallNotification: vi.fn(),
showMissedCallNotification: vi.fn(),
cancelIncomingCallNotification: vi.fn(),
+ cancelMessageNotifications: vi.fn(),
+ cancelAllMessageNotifications: vi.fn(),
+ setOpenConversationHashes: vi.fn(),
+ setDoNotDisturbEnabled: vi.fn(),
};
globalThis.Notification = vi.fn(function (title, opts) {
- return { title, opts };
+ this.title = title;
+ this.opts = opts;
+ this.close = vi.fn();
+ return this;
});
globalThis.Notification.requestPermission = vi.fn().mockResolvedValue("granted");
});
@@ -34,14 +45,19 @@ describe("NotificationUtils", () => {
globalThis.electron = electronMock;
});
- it("showNewMessageNotification delegates to electron", () => {
- NotificationUtils.showNewMessageNotification("Alice", "hello");
- expect(electronMock.showNotification).toHaveBeenCalledWith("New Message", "Alice: hello", false);
+ it("showNewMessageNotification delegates to electron with destination hash", () => {
+ NotificationUtils.showNewMessageNotification("Alice", "hello", false, "abcd");
+ expect(electronMock.showNotification).toHaveBeenCalledWith("New Message", "Alice: hello", false, "abcd");
});
it("showNewMessageNotification passes silent flag to electron", () => {
- NotificationUtils.showNewMessageNotification("Alice", "hello", true);
- expect(electronMock.showNotification).toHaveBeenCalledWith("New Message", "Alice: hello", true);
+ NotificationUtils.showNewMessageNotification("Alice", "hello", true, "abcd");
+ expect(electronMock.showNotification).toHaveBeenCalledWith("New Message", "Alice: hello", true, "abcd");
+ });
+
+ it("clearMessageNotifications delegates to electron", () => {
+ NotificationUtils.clearMessageNotifications("abcd");
+ expect(electronMock.closeMessageNotifications).toHaveBeenCalledWith("abcd");
});
it("showIncomingCallNotification delegates to electron", () => {
@@ -71,9 +87,25 @@ describe("NotificationUtils", () => {
globalThis.MeshChatXAndroid = androidMock;
});
- it("showNewMessageNotification delegates to Android bridge", () => {
- NotificationUtils.showNewMessageNotification("Alice", "hello");
- expect(androidMock.showNotification).toHaveBeenCalledWith("New Message", "Alice: hello");
+ it("showNewMessageNotification does not post OS notifs (push bridge owns them)", () => {
+ NotificationUtils.showNewMessageNotification("Alice", "hello", false, "abcd");
+ expect(androidMock.showNotification).not.toHaveBeenCalled();
+ });
+
+ it("clearMessageNotifications cancels by destination hash", () => {
+ NotificationUtils.clearMessageNotifications("abcd");
+ expect(androidMock.cancelMessageNotifications).toHaveBeenCalledWith("abcd");
+ });
+
+ it("clearAllMessageNotifications cancels all", () => {
+ NotificationUtils.clearAllMessageNotifications();
+ expect(androidMock.cancelAllMessageNotifications).toHaveBeenCalled();
+ });
+
+ it("syncAndroidNotificationContext pushes open peers and DND", () => {
+ NotificationUtils.syncAndroidNotificationContext(["AAAA", "bbbb"], true);
+ expect(androidMock.setOpenConversationHashes).toHaveBeenCalledWith("aaaa,bbbb");
+ expect(androidMock.setDoNotDisturbEnabled).toHaveBeenCalledWith(true);
});
it("showIncomingCallNotification delegates to Android bridge", () => {
@@ -89,14 +121,6 @@ describe("NotificationUtils", () => {
);
});
- it("showNewVoicemailNotification delegates to Android bridge", () => {
- NotificationUtils.showNewVoicemailNotification("Dave");
- expect(androidMock.showNotification).toHaveBeenCalledWith(
- "New Voicemail",
- "You have a new voicemail from Dave."
- );
- });
-
it("cancelIncomingCallNotification delegates to Android bridge", () => {
NotificationUtils.cancelIncomingCallNotification();
expect(androidMock.cancelIncomingCallNotification).toHaveBeenCalled();
@@ -104,14 +128,26 @@ describe("NotificationUtils", () => {
});
describe("Browser fallback", () => {
- it("showNewMessageNotification uses browser Notification API", async () => {
- NotificationUtils.showNewMessageNotification("Alice", "hello");
+ it("showNewMessageNotification uses per-peer tag", async () => {
+ NotificationUtils.showNewMessageNotification("Alice", "hello", false, "abcd1234");
await new Promise((r) => setTimeout(r, 10));
expect(globalThis.Notification).toHaveBeenCalledWith(
"New Message",
- expect.objectContaining({ body: "Alice: hello" })
+ expect.objectContaining({ body: "Alice: hello", tag: "lxmf-abcd1234" })
);
});
+
+ it("clearMessageNotifications closes tracked web notifications", async () => {
+ NotificationUtils.showNewMessageNotification("Alice", "hello", false, "peer1");
+ await new Promise((r) => setTimeout(r, 10));
+ const instance = globalThis.Notification.mock.results[0].value;
+ NotificationUtils.clearMessageNotifications("peer1");
+ expect(instance.close).toHaveBeenCalled();
+ });
+
+ it("clear unknown hash is a no-op", () => {
+ expect(() => NotificationUtils.clearMessageNotifications("missing")).not.toThrow();
+ });
});
describe("_isAndroid detection", () => {

diff --git a/tests/frontend/notificationPolicy.test.js b/tests/frontend/notificationPolicy.test.js
new file mode 100644
index 00000000..854cd6ab
--- /dev/null
+++ b/tests/frontend/notificationPolicy.test.js
@@ -0,0 +1,205 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, it, expect, beforeEach } from "vitest";
+import {
+ shouldShowOsMessageNotification,
+ shouldPlayMessageSound,
+ isUserFacingLxmfDeliveryMessage,
+ deliverySourceHash,
+} from "../../meshchatx/src/frontend/js/notificationPolicy.js";
+import {
+ setOpenDestinationHashes,
+ listOpenDestinationHashes,
+ clearOpenDestinationHashesForTests,
+ hasOpenDestinationHash,
+} from "../../meshchatx/src/frontend/js/activeConversationStore.js";
+
+const peerA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+const peerB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+
+describe("notificationPolicy", () => {
+ it("DND blocks OS and sound", () => {
+ expect(
+ shouldShowOsMessageNotification({
+ isIncoming: true,
+ dnd: true,
+ hasFocus: false,
+ userFacing: true,
+ sourceHash: peerA,
+ })
+ ).toBe(false);
+ expect(
+ shouldPlayMessageSound({
+ isIncoming: true,
+ dnd: true,
+ hasFocus: true,
+ userFacing: true,
+ })
+ ).toBe(false);
+ });
+
+ it("sieve suppress blocks OS and sound", () => {
+ expect(
+ shouldShowOsMessageNotification({
+ isIncoming: true,
+ sieveSuppress: true,
+ hasFocus: false,
+ userFacing: true,
+ sourceHash: peerA,
+ })
+ ).toBe(false);
+ expect(
+ shouldPlayMessageSound({
+ isIncoming: true,
+ sieveSuppress: true,
+ hasFocus: true,
+ userFacing: true,
+ })
+ ).toBe(false);
+ });
+
+ it("open peer A + msg A + focused: no OS, sound ok", () => {
+ expect(
+ shouldShowOsMessageNotification({
+ isIncoming: true,
+ hasFocus: true,
+ openDestinationHashes: [peerA],
+ sourceHash: peerA,
+ userFacing: true,
+ })
+ ).toBe(false);
+ expect(
+ shouldPlayMessageSound({
+ isIncoming: true,
+ hasFocus: true,
+ userFacing: true,
+ })
+ ).toBe(true);
+ });
+
+ it("open peer A + msg A + blurred: no OS", () => {
+ expect(
+ shouldShowOsMessageNotification({
+ isIncoming: true,
+ hasFocus: false,
+ openDestinationHashes: [peerA],
+ sourceHash: peerA,
+ userFacing: true,
+ })
+ ).toBe(false);
+ });
+
+ it("open A + msg B + focused: no OS, sound for B", () => {
+ expect(
+ shouldShowOsMessageNotification({
+ isIncoming: true,
+ hasFocus: true,
+ openDestinationHashes: [peerA],
+ sourceHash: peerB,
+ userFacing: true,
+ })
+ ).toBe(false);
+ expect(
+ shouldPlayMessageSound({
+ isIncoming: true,
+ hasFocus: true,
+ userFacing: true,
+ })
+ ).toBe(true);
+ });
+
+ it("open A + msg B + blurred: OS for B", () => {
+ expect(
+ shouldShowOsMessageNotification({
+ isIncoming: true,
+ hasFocus: false,
+ openDestinationHashes: [peerA],
+ sourceHash: peerB,
+ userFacing: true,
+ })
+ ).toBe(true);
+ });
+
+ it("no open peers + blurred: OS", () => {
+ expect(
+ shouldShowOsMessageNotification({
+ isIncoming: true,
+ hasFocus: false,
+ openDestinationHashes: [],
+ sourceHash: peerA,
+ userFacing: true,
+ })
+ ).toBe(true);
+ });
+
+ it("outbound: no OS", () => {
+ expect(
+ shouldShowOsMessageNotification({
+ isIncoming: false,
+ hasFocus: false,
+ sourceHash: peerA,
+ userFacing: true,
+ })
+ ).toBe(false);
+ });
+
+ it("non-user-facing: no OS", () => {
+ expect(
+ shouldShowOsMessageNotification({
+ isIncoming: true,
+ hasFocus: false,
+ sourceHash: peerA,
+ userFacing: false,
+ })
+ ).toBe(false);
+ });
+
+ it("isUserFacingLxmfDeliveryMessage filters reactions and empty telemetry", () => {
+ expect(isUserFacingLxmfDeliveryMessage({ is_reaction: true, content: "" })).toBe(false);
+ expect(
+ isUserFacingLxmfDeliveryMessage({
+ content: "",
+ title: "",
+ fields: { reaction: { reaction_to: "abc" } },
+ })
+ ).toBe(false);
+ expect(
+ isUserFacingLxmfDeliveryMessage({
+ content: "",
+ title: "",
+ fields: { telemetry: { x: 1 } },
+ })
+ ).toBe(false);
+ expect(isUserFacingLxmfDeliveryMessage({ content: "hi", title: "" })).toBe(true);
+ expect(
+ isUserFacingLxmfDeliveryMessage({
+ content: "",
+ title: "",
+ fields: { image: { image_bytes: "x" } },
+ })
+ ).toBe(true);
+ });
+
+ it("deliverySourceHash prefers lxmf_message.source_hash", () => {
+ expect(
+ deliverySourceHash({
+ remote_identity_hash: peerB,
+ lxmf_message: { source_hash: peerA },
+ })
+ ).toBe(peerA);
+ });
+});
+
+describe("activeConversationStore", () => {
+ beforeEach(() => {
+ clearOpenDestinationHashesForTests();
+ });
+
+ it("tracks open destination hashes", () => {
+ setOpenDestinationHashes([peerA, peerB, ""]);
+ expect(listOpenDestinationHashes().sort()).toEqual([peerA, peerB].sort());
+ expect(hasOpenDestinationHash(peerA.toUpperCase())).toBe(true);
+ setOpenDestinationHashes([]);
+ expect(listOpenDestinationHashes()).toEqual([]);
+ });
+});

diff --git a/tests/test_android_push_bridge.py b/tests/test_android_push_bridge.py
index 8ddfb011..1f0773c1 100644
--- a/tests/test_android_push_bridge.py
+++ b/tests/test_android_push_bridge.py
@@ -103,20 +103,24 @@ def test_lxmf_delivery_truncates_long_content():
assert len(body) == 200
-def test_lxmf_delivery_reaction():
- assert lxmf_delivery_notification_text(
- {
- "type": "lxmf.delivery",
- "remote_identity_name": "Eve",
- "lxmf_message": {
- "is_incoming": True,
- "is_reaction": True,
- "reaction_emoji": "thumbsup",
- "title": "",
- "content": "",
+def test_lxmf_delivery_skips_reaction():
+ assert (
+ lxmf_delivery_notification_text(
+ {
+ "type": "lxmf.delivery",
+ "remote_identity_name": "Eve",
+ "lxmf_message": {
+ "is_incoming": True,
+ "is_reaction": True,
+ "reaction_emoji": "thumbsup",
+ "title": "",
+ "content": "",
+ "fields": {"reaction": {"reaction_to": "deadbeef"}},
+ },
},
- },
- ) == ("Eve", "Reaction thumbsup")
+ )
+ is None
+ )
def test_lxmf_delivery_default_sender():
@@ -181,19 +185,93 @@ def test_lxmf_delivery_attachment_fields_only():
) == ("Hal", "Attachment")
-def test_lxmf_delivery_skips_non_dict_message():
+def test_lxmf_delivery_skips_when_open_conversation(monkeypatch):
+ monkeypatch.setattr(android_push_bridge, "_is_open_conversation", lambda _h: True)
+ assert (
+ lxmf_delivery_notification_text(
+ {
+ "type": "lxmf.delivery",
+ "remote_identity_name": "Open",
+ "lxmf_message": {
+ "is_incoming": True,
+ "source_hash": "a" * 32,
+ "title": "Hi",
+ "content": "Body",
+ },
+ },
+ )
+ is None
+ )
+
+
+def test_lxmf_delivery_skips_when_dnd(monkeypatch):
+ monkeypatch.setattr(android_push_bridge, "_dnd_enabled", lambda: True)
assert (
lxmf_delivery_notification_text(
{
"type": "lxmf.delivery",
- "remote_identity_name": "Ian",
- "lxmf_message": "not-a-dict",
+ "remote_identity_name": "Quiet",
+ "lxmf_message": {
+ "is_incoming": True,
+ "source_hash": "b" * 32,
+ "title": "Hi",
+ "content": "Body",
+ },
},
)
is None
)
+def test_lxmf_delivery_notifies_other_peer_while_one_open(monkeypatch):
+ monkeypatch.setattr(
+ android_push_bridge,
+ "_is_open_conversation",
+ lambda h: h == "a" * 32,
+ )
+ monkeypatch.setattr(android_push_bridge, "_dnd_enabled", lambda: False)
+ assert lxmf_delivery_notification_text(
+ {
+ "type": "lxmf.delivery",
+ "remote_identity_name": "Other",
+ "lxmf_message": {
+ "is_incoming": True,
+ "source_hash": "b" * 32,
+ "title": "Hi",
+ "content": "Body",
+ },
+ },
+ ) == ("Other", "Hi\nBody")
+
+
+def test_after_broadcast_passes_destination_hash(monkeypatch):
+ calls = []
+
+ def fake_notify(title, body, dedupe, destination_hash=None):
+ calls.append((title, body, dedupe, destination_hash))
+
+ monkeypatch.setattr(android_push_bridge, "_notify_java", fake_notify)
+ monkeypatch.setattr(android_push_bridge, "_dnd_enabled", lambda: False)
+ monkeypatch.setattr(android_push_bridge, "_is_open_conversation", lambda _h: False)
+ payload = json.dumps(
+ {
+ "type": "lxmf.delivery",
+ "remote_identity_name": "Zed",
+ "lxmf_message": {
+ "is_incoming": True,
+ "hash": "cafebabe" + "0" * 24,
+ "source_hash": "d" * 32,
+ "title": "T",
+ "content": "C",
+ },
+ },
+ )
+ android_push_bridge._after_websocket_broadcast(payload)
+ assert calls
+ assert calls[0][0] == "Zed"
+ assert calls[0][3] == "d" * 32
+
+
def test_after_websocket_broadcast_ignores_non_string(monkeypatch):
calls = []
monkeypatch.setattr(android_push_bridge, "_notify_java", lambda *a: calls.append(a))


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────